45 — 后处理

Three.js Journey · Bruno Simon · 难度:简单

45 — 后处理 课程海报

介绍

Post-processing 是在最终图像(渲染结果)上添加效果。人们大多在电影制作中使用这种技术,但我们也可以在 WebGL 中实现。

Post-processing 可以很微妙,用来稍微改善图像,也可以创造巨大的效果。

以下是一些你可以使用 post-processing 的场景示例:

设置

我们将使用与 Realistic Model Render 课程相同的设置,但使用 Damaged Helmet 模型(由 Leonardo Carrion 制作)。这是一个流行的模型,细节丰富、texture 出色,应该能与我们的 post-processing 很好地搭配。

工作原理

大多数情况下,post-processing 的工作方式都是相同的。

渲染目标

我们不在 canvas 中进行渲染,而是在所谓的 render target 中进行渲染。这个 render target 会给我们一个与普通 texture 非常相似的 texture。简单来说,我们渲染到一个 texture 中,而不是屏幕上的 canvas。

“render target”这个术语是 Three.js 特有的。其他语境大多使用“buffer”这个词。

然后,这个 texture 被应用到一个面向 camera 并覆盖整个视野的平面(plane)上。这个平面使用带有特殊 fragment shader 的 material 来执行 post-processing 效果。如果 post-processing 效果是让图像变红,那么在那个 fragment shader 中,它只需要乘以像素的红色值即可。

大多数 post-process 效果所做的远不止调整颜色值,但你应该明白这个思路了。

在 Three.js 中,这些“效果”被称为“pass”。从现在起,我们将使用这个术语。

乒乓缓冲

我们的 post-process 可以有多个 pass:一个做运动模糊,一个做颜色变化,一个做景深,等等。因为可以有多个 pass,所以 post-processing 需要两个 render target。原因是我们无法在向一个 render target 绘制的同时读取它的 texture。思路是:在第一个 render target 中绘制,同时从第二个中取 texture。到了下一个 pass,我们交换这两个 render target,从第二个取 texture,在第一个上绘制。再到下一个 pass,再交换,如此反复。这就是我们所说的 ping pong buffering。

在 canvas 上的最终 pass

最后一个 pass 不会在 render target 中进行,因为我们可以把它直接放到 canvas 上,让用户看到最终结果。

最后

对初学者来说,这些可能非常复杂,但幸运的是,我们不必自己动手实现。

其实,我们本可以在不解释这些 render target、textures、ping pong buffering 等概念的情况下就开始,但理解幕后究竟发生了什么总是有好处的。

我们只需使用 EffectComposer 类,它就会为我们承担大部分繁重的工作。

EffectComposer

如前所述,EffectComposer 会处理整个过程:创建 render target、执行 ping-pong 操作、把上一个 pass 的 texture 发送到当前 pass、把最后一个绘制到 canvas 上,等等。

首先,我们需要导入它,因为它不在 THREE 变量中:

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'

我们还需要一个名为 RenderPass 的第一个 pass。这个 pass 负责我们 scene 的首次渲染,但它不是在 canvas 中进行,而是在 EffectComposer 内部创建的 render target 中进行:

import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'

现在我们可以实例化 EffectComposer,并把 renderer 作为参数传入。与 WebGLRenderer 一样,我们需要用 setPixelRatio(...) 提供像素比,并用 setSize(...) 调整大小。我们将使用与 renderer 相同的参数:

/**
 * Post processing
 */
const effectComposer = new EffectComposer(renderer)
effectComposer.setSize(sizes.width, sizes.height)
effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

然后我们可以实例化第一个 pass,并用 addPass(...) 方法把它添加到 effectComposer。RenderPass 需要 scene 和 camera 作为参数:

const renderPass = new RenderPass(scene, camera)
effectComposer.addPass(renderPass)

在 tick 函数中,我们不再像以往那样进行渲染,而是用 effectComposer 来完成渲染。把 renderer.render(...) 替换为以下代码:

const tick = () =>
{
    // ...

    // Render
    // renderer.render(scene, camera)
    effectComposer.render()

    // ...
}

effectComposer 将开始用 ping pong 机制及其 render target 进行渲染。但由于我们只有一个 pass —— renderPass —— 它会像之前一样直接渲染到 canvas 中。

现在该添加一些漂亮的 post-process pass 了。

你可以在文档中找到可用 pass 的列表:https://threejs.org/docs/index.html#examples/en/postprocessing/EffectComposer

我们将使用其中一些,看看如何设置,然后创建我们自己的 pass。

DotScreenPass

DotScreenPass 会应用某种黑白光栅(raster)效果。我们只需导入 DotScreenPass:

import { DotScreenPass } from 'three/examples/jsm/postprocessing/DotScreenPass.js'

实例化它并添加到 effectComposer。确保把它添加在 renderPass 之后:

const dotScreenPass = new DotScreenPass()
effectComposer.addPass(dotScreenPass)

要禁用一个 pass,只需注释掉它,或把它的 enabled 属性改为 false:

const dotScreenPass = new DotScreenPass()
dotScreenPass.enabled = false
effectComposer.addPass(dotScreenPass)

用它来分别测试不同的 pass。

GlitchPass

GlitchPass 会添加屏幕故障(glitch)效果,就像电影里 camera 被黑客入侵时的样子。

像 DotScreenPass 那样导入并添加它:

import { GlitchPass } from 'three/examples/jsm/postprocessing/GlitchPass.js'

// ...

const glitchPass = new GlitchPass()
effectComposer.addPass(glitchPass)
lesson demo video preview

有些 pass 还有可编辑的属性。GlitchPass 有一个 goWild 属性,如果设为 true,就会产生不间断的 glitch:

如果你对闪光或快速运动敏感,请务必小心!

glitchPass.goWild = true

RGBShiftPass

有些 pass 需要额外的工作,比如 RGBShift pass。

RGBShift 并不是以 pass 的形式提供的,而是以 shader 的形式。我们需要导入这个 shader,把它应用到一个 ShaderPass 上,然后把这个 ShaderPass 添加到 effectComposer。这正是 DotScreenPass 和 GlitchPass 内部所做的事情,只不过这次我们要自己动手。

首先,导入位于 three/examples/jsm/shaders/ 中的 ShaderPass 和 RGBShiftShader:

import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'
import { RGBShiftShader } from 'three/examples/jsm/shaders/RGBShiftShader.js'

然后用 RGBShiftShader 作为参数实例化 ShaderPass,并把它添加到 effectComposer:

// ...

const rgbShiftPass = new ShaderPass(RGBShiftShader)
effectComposer.addPass(rgbShiftPass)

就这样。

修复颜色

你可能注意到渲染结果出现了颜色变化,好像一切都变暗了,你没错。禁用之前的 dotScreenPass 和 rgbShiftPass,就能更清楚地通过 glitchPass 看到这一点 —— 不带 goWild 的情况下。

这里发生的情况是 renderer.colorSpace = THREE.SRGBColorSpace 不再起作用了。你可以把它注释掉,会发现毫无区别。pass 在 render target 中渲染,而这些 render target 对颜色空间(color space)的支持方式不同。

我们需要再添加一个名为 GammaCorrectionShader 的 pass,它会把颜色空间转换为 SRGB。

这个 pass 的工作方式与 RGBShiftShader pass 完全相同。首先,我们需要导入它:

import { GammaCorrectionShader } from 'three/examples/jsm/shaders/GammaCorrectionShader.js'

然后,我们可以用 GammaCorrectionShader 作为参数实例化一个 ShaderPass。请确保把它作为最后一个 pass:

// ...

const gammaCorrectionPass = new ShaderPass(GammaCorrectionShader)
effectComposer.addPass(gammaCorrectionPass)

颜色应该就恢复正常了。

如果你想了解更多关于 Gamma 颜色校正的内容,请查看这篇 What Every Coder Should Know About Gamma 文章。

调整大小

把窗口缩小到最小分辨率,刷新后再把分辨率增大到最大。一切看起来都会很糟,就像一张被拉伸放大的小图。

lesson demo video preview

这是因为 EffectComposer 及其 pass 需要调整大小。我们还需要像对 renderer 那样设置合适的像素比。

在 window.addEventListener('resize', ...) 回调函数中,像我们实例化 EffectComposer 时那样调用 setSize(...) 和 setPixelRatio(...) 方法:

window.addEventListener('resize', () =>
{
    // ...

    // Update effect composer
    effectComposer.setSize(sizes.width, sizes.height)
    effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})
lesson demo video preview

你可以随意调整窗口大小,分辨率应该都没问题。

修复抗锯齿

还有另一个功能看起来也失效了。如果你观察头盔的边缘,会发现锯齿(aliasing)又回来了 —— 如果你使用的屏幕像素比高于 1,你可能看不到这个问题。

注意:如果你只有 renderPass 可用,是看不到这个问题的,因为渲染在支持抗锯齿的 canvas 中完成。至少启用一个 pass 才能看到问题。

这是因为 EffectComposer 使用的 render target 不支持默认的抗锯齿。我们有四个可选的方案:

事情突然变得复杂了。

将抗锯齿添加到 render target

默认情况下,EffectComposer 使用的是不带抗锯齿的 WebGLRenderTarget。

幸运的是,我们可以把我们自己的 render target 作为 EffectComposer 的第二个参数传入。我们先提供相同的 render target,确保一切正常。然后,我们再添加抗锯齿。

如果你查看位于 /node_modules/three/examples/jsm/postprocessing/EffectComposer.js 的 EffectComposer 代码,你会看到 renderTarget 是用特定参数实例化的。

前两个参数是 width 和 height。我们可以使用随机的值,因为当在 effectComposer 上调用 setSize(...) 函数时,render target 会被调整大小:

const renderTarget = new THREE.WebGLRenderTarget(
    800,
    600
)

然后,我们可以把这个 renderTarget 传给 effectComposer:

const effectComposer = new EffectComposer(renderer, renderTarget)

我们应该会得到完全相同的结果,但现在我们能控制 render target 了。

WebGLRenderTarget 类实际上可以接收第三个参数,它是一个包含一些选项的对象。

我们需要提供的唯一选项是能启用抗锯齿的 samples 属性:

const renderTarget = new THREE.WebGLRenderTarget(
    800,
    600,
    {
        samples: 2
    }
)

samples 越多,抗锯齿效果越好,0 表示完全没有采样。注意,该值每增加一点都会降低性能。

如前所述,如果用户的像素比高于 1,像素密度已经足够高,无法分辨出锯齿。在这种情况下,我们其实不需要抗锯齿,应该把 samples 属性设为 1。

我们可以用 samples: renderer.getPixelRatio() 从 renderer 中获取像素比:

const renderTarget = new THREE.WebGLRenderTarget(
    800,
    600,
    {
        samples: renderer.getPixelRatio() === 1 ? 2 : 0
    }
)

就这样!

遗憾的是,这并非对所有浏览器都有效。这涉及 WebGL 2 的支持情况。人们几年前更新了 WebGL,浏览器会慢慢为不同的功能添加支持。你可以在这里查看支持进展:https://caniuse.com/#feat=webgl2

在撰写本课程时,像 Safari 和 iOS Safari 这样的主流浏览器最近才支持它。

使用抗锯齿 pass

让我们注释掉 samples 属性,以便正确测试抗锯齿 pass:

const renderTarget = new THREE.WebGLRenderTarget(
    800,
    600,
    {
        // samples: renderer.getPixelRatio() === 1 ? 2 : 0
    }
)

我们对抗锯齿 pass 有不同的选择:

选择最佳的抗锯齿 pass 取决于性能和视觉预期。不断尝试它们,直到你对在合理帧率下看到的效果感到满意。

在本课程中,我们将使用 SMAA。

之前我们说过应该把 gammaCorrectionPass 放在最后,但为了优化,抗锯齿 pass 应该添加在它之后。

导入 SMAAPass,实例化它,并添加到 effectComposer:

import { SMAAPass } from 'three/examples/jsm/postprocessing/SMAAPass.js'

// ...

const smaaPass = new SMAAPass()
effectComposer.addPass(smaaPass)

锯齿应该消失了。

结合两种方案

我们之前添加到 WebGLRenderTarget 的 samples 属性的妙处在于:如果它不被支持(因为浏览器使用的是 WebGL 1),它会被忽略,而不会触发任何错误。

这意味着我们可以保持原样,只在用户的屏幕像素比等于 1 且其配置不支持 WebGL 2 时才添加 SMAAPass。

要知道浏览器是否支持 WebGL 2,我们可以使用 renderer 上的 capabilities 属性。该属性包含许多关于支持哪些功能的细节。我们需要的属性是 isWebGL2:

if(renderer.getPixelRatio() === 1 && !renderer.capabilities.isWebGL2)
{
    const smaaPass = new SMAAPass()
    effectComposer.addPass(smaaPass)

    console.log('Using SMAA')
}

这样我们就能在每种浏览器上以最小的代价获得出色的画面。

如果你想用 WebGL1 测试你的代码,可以把 WebGLRenderer 替换为 WebGL1Renderer:

const renderer = new THREE.WebGL1Renderer({
    // ...
})

测试完成后别忘了改回来。

UnrealBloomPass

让我们回到 pass,来看可能是最酷的一个:UnrealBloomPass。

这个 pass 会为我们的渲染添加看起来非常惊艳的 bloom 效果。它很适合用来重现光晕、火焰热浪、激光、光剑或放射性物质之类的效果。

导入 UnrealBloomPass 并添加到 effectComposer:

import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'

// ...

const unrealBloomPass = new UnrealBloomPass()
effectComposer.addPass(unrealBloomPass)

一切看起来都太亮了。我们需要调整一些参数。主要有 3 个参数:

使用以下参数,并把调整项添加到你的 Dat.GUI 中:

unrealBloomPass.strength = 0.3
unrealBloomPass.radius = 1
unrealBloomPass.threshold = 0.6

gui.add(unrealBloomPass, 'enabled')
gui.add(unrealBloomPass, 'strength').min(0).max(2).step(0.001)
gui.add(unrealBloomPass, 'radius').min(0).max(2).step(0.001)
gui.add(unrealBloomPass, 'threshold').min(0).max(1).step(0.001)
lesson demo video preview

这可能太亮了,但你明白这个思路。

创建我们自己的 pass

创建我们自己的 pass 就像编写自定义 shader 一样简单。

色调(Tint)pass

我们将从一个简单的 pass 开始,它让我们可以控制颜色色调。

首先,我们创建一个 shader。shader 是一个具有以下属性的简单对象:

让我们用最少的代码创建这个 shader:

const TintShader = {
    uniforms:
    {
    },
    vertexShader: `
        void main()
        {
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        void main()
        {
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    `
}

然后我们用 ShaderPass 创建这个 pass,并添加到我们的 effectComposer:

const tintPass = new ShaderPass(TintShader)
effectComposer.addPass(tintPass)

屏幕应该变红,因为我们的 fragment shader 把 gl_FragColor 设成了红色。

我们需要从上一个 pass 获取 texture。这个 texture 会自动存储在 tDiffuse uniform 中。我们必须添加这个 uniform,值为 null —— EffectComposer 会更新它 —— 并在 fragmentShader 中取出该值:

const TintShader = {
    uniforms:
    {
        tDiffuse: { value: null }
    },

    // ...

    fragmentShader: `
        uniform sampler2D tDiffuse;
        
        void main()
        {
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    `
}

现在我们有了上一个 pass 的 texture,需要像之前的课程那样取出像素。要从 sampler2D(一个 texture)中获取像素,我们需要使用 texture2D(...)。它需要第一个参数为 texture,第二个参数为 UV 坐标。

问题是我们现在还没有这些 UV 坐标。我们需要像往常一样,创建一个名为 vUv 的 varying,它包含来自 vertex shader 的 uv:

const TintShader = {

    // ...

    vertexShader: `
        varying vec2 vUv;

        void main()
        {
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

            vUv = uv;
        }
    `,
    fragmentShader: `
        uniform sampler2D tDiffuse;

        varying vec2 vUv;

        void main()
        {
            vec4 color = texture2D(tDiffuse, vUv);
            gl_FragColor = color;
        }
    `
}

渲染结果又回来了。但现在,我们可以在 fragmentShader 中摆弄这个 texture 了。

要改变色调,请调整 color 的 r、g 和 b 属性:

const TintShader = {

    // ...

    fragmentShader: `
        uniform sampler2D tDiffuse;

        varying vec2 vUv;

        void main()
        {
            vec4 color = texture2D(tDiffuse, vUv);
            color.r += 0.1;

            gl_FragColor = color;
        }
    `
}

更进一步,让我们创建一个 uniform 来控制色调。首先,把 uTint 添加到 uniforms 中:

const TintShader = {
    uniforms:
    {
        tDiffuse: { value: null },
        uTint: { value: null }
    },

    // ...

    fragmentShader: `
        uniform sampler2D tDiffuse;
        uniform vec3 uTint;

        varying vec2 vUv;

        void main()
        {
            vec4 color = texture2D(tDiffuse, vUv);
            color.rgb += uTint;

            gl_FragColor = color;
        }
    `
}

如你所见,我们把值留作 null。不要在 shader 对象中直接设置这些值。你必须在创建 pass 之后在 material 上设置它们,因为 shader 是要被多次使用的 —— 即使你并不这么做。它就像是 pass 的模板:

const tintPass = new ShaderPass(TintShader)
tintPass.material.uniforms.uTint.value = new THREE.Vector3()

然后我们可以把调整项添加到我们的 lil-gui 中:

gui.add(tintPass.material.uniforms.uTint.value, 'x').min(- 1).max(1).step(0.001).name('red')
gui.add(tintPass.material.uniforms.uTint.value, 'y').min(- 1).max(1).step(0.001).name('green')
gui.add(tintPass.material.uniforms.uTint.value, 'z').min(- 1).max(1).step(0.001).name('blue')
lesson demo video preview

位移(Displacement)pass

让我们再试另一个自定义 pass。这次我们不摆弄颜色,而是摆弄 UV,以产生我们所说的位移(displacement)。

创建一个名为 DisplacementShader 的新 shader,然后用 ShaderPass 创建一个名为 displacementPass 的新 pass,并把它添加到 effectComposer:

const DisplacementShader = {
    uniforms:
    {
        tDiffuse: { value: null }
    },
    vertexShader: `
        varying vec2 vUv;

        void main()
        {
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

            vUv = uv;
        }
    `,
    fragmentShader: `
        uniform sampler2D tDiffuse;

        varying vec2 vUv;

        void main()
        {
            vec4 color = texture2D(tDiffuse, vUv);

            gl_FragColor = color;
        }
    `
}

const displacementPass = new ShaderPass(DisplacementShader)
effectComposer.addPass(displacementPass)

现在,让我们基于 vUv 创建一个带有一些扭曲的 newUv:

const DisplacementShader = {

    // ...

    fragmentShader: `
        uniform sampler2D tDiffuse;

        varying vec2 vUv;

        void main()
        {
            vec2 newUv = vec2(
                vUv.x,
                vUv.y + sin(vUv.x * 10.0) * 0.1
            );
            vec4 color = texture2D(tDiffuse, newUv);

            gl_FragColor = color;
        }
    `
}
lesson demo video preview

这里,我们仅根据 x 轴在 y 轴上应用了一个 sin(...)。你应该能看到渲染结果在波动。

让我们让它动起来。添加一个 uTime uniform:

const DisplacementShader = {
    uniforms:
    {
        tDiffuse: { value: null },
        uTime: { value: null }
    },
    
    // ...

    fragmentShader: `
        uniform sampler2D tDiffuse;
        uniform float uTime;

        varying vec2 vUv;

        void main()
        {
            vec2 newUv = vec2(
                vUv.x,
                vUv.y + sin(vUv.x * 10.0 + uTime) * 0.1
            );
            vec4 color = texture2D(tDiffuse, newUv);

            gl_FragColor = color;
        }
    `
}

创建 pass 之后把它的值设为 0:

const displacementPass = new ShaderPass(DisplacementShader)
displacementPass.material.uniforms.uTime.value = 0
effectComposer.addPass(displacementPass)

和往常一样,在 tick 函数中更新它:

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Update passes
    displacementPass.material.uniforms.uTime.value = elapsedTime

    // ...
}
lesson demo video preview

波浪现在动起来了。

未来感界面位移

除了正弦位移,我们还可以使用一个 texture。你可以在 /static/textures/interfaceNormalMap.png 中找到一个非常简洁、带有 normal texture 的蜂巢状未来感界面。

添加一个 uNormalMap uniform:

const DisplacementShader = {
    uniforms:
    {
        // ...
        uNormalMap: { value: null }
    },

    // ...
}

在加载 texture 时更新它 —— TextureLoader 已经在代码中了:

displacementPass.material.uniforms.uNormalMap.value = textureLoader.load('/textures/interfaceNormalMap.png')

现在更新 DisplacementShader 的 fragmentShader:

const DisplacementShader = {
    // ...

    fragmentShader: `
        uniform sampler2D tDiffuse;
        uniform float uTime;
        uniform sampler2D uNormalMap;

        varying vec2 vUv;

        void main()
        {
            vec3 normalColor = texture2D(uNormalMap, vUv).xyz * 2.0 - 1.0;
            vec2 newUv = vUv + normalColor.xy * 0.1;
            vec4 color = texture2D(tDiffuse, newUv);

            vec3 lightDirection = normalize(vec3(- 1.0, 1.0, 0.0));
            float lightness = clamp(dot(normalColor, lightDirection), 0.0, 1.0);
            color.rgb += lightness * 2.0;

            gl_FragColor = color;
        }
    `
}
lesson demo video preview

我们不会透露这里发生了什么,因为这并不是实现该效果的正确方式,但你应该能看到一个引人注目的界面位移效果。遗憾的是,这个 texture 会按屏幕大小适配,如果你的分辨率不成比例,它看起来就不会太好。别担心,反正这只是为了演示。

进阶

你现在可以做的是尝试其他 pass,如果你有了些想法,或者有想尝试的东西,也可以添加新的自定义 pass。

请记住,你添加的每个 pass 都需要在每一帧渲染。这可能会带来严重的性能损失。

你也可以把自定义 pass 拆分到不同的文件中,甚至把 shader 拆分到 .glsl 文件里。这样,你的代码会更清晰、更可复用。